Skip to main content

Análise: Desconto em Itens do Carrinho e Recomendados

Fonte de verdade: Código-fonte do aplicativo
Data de análise: 09/06/2026
Repositório: coezzion_vendas_app


Visão Geral

Esta análise documenta o comportamento atual do sistema de descontos ao operar sobre itens do carrinho e itens recomendados, separando as regras por fluxo de aplicação: desconto funcionário (gate por elegibilidade da API) e desconto manual (Novo Preço/Percentual).

Atenção: o gate que ativa o fluxo de funcionário é EmployeeSalesController.isEligible.valuenão customer.isEmployee. Cliente funcionário pode cair no fluxo manual se a API retornar isEligible=false ou se a tela nem chegou a chamar a API (canal/saleEcommerce). Ver Seção 2.1.

Os itens recomendados são armazenados em cart.recommendedItems (lista separada de cart.cartItems) e toda mutação sobre eles usa changeWithSkipTotals, o que significa que valores de recomendados nunca recalculam totais do pedido.


1. Separação Estrutural: Cart vs Recomendados

PropriedadecartItemsrecommendedItems
ModeloList<CartItemDTO>List<CartItemDTO>?
Computa itemsDiscountSimNão
Computa totalWithoutShipmentAndDiscountSimNão
Recalcula totais ao mutarSim (changeWithAsync)Não (changeWithSkipTotals)
Enviado na APISimSim (cartItemRecommendations)

Arquivo-chave: cart_controller.dart linha 612

/// Aplica mutação no cart sem recalcular totais.
/// Usado exclusivamente para operações em recommendedItems.
CartDTO? changeWithSkipTotals(ChangeCartFunction change) { ... }

2. Desconto Funcionário (gate por elegibilidade)

Importante: o gate que ativa este fluxo é EmployeeSalesController.isEligible.value, não customer.isEmployee. Cliente funcionário com elegibilidade negada pela API (ex.: cota anual esgotada) cai no fluxo de Não-Funcionário descrito na Seção 3.

2.1. Gate em Duas Camadas

Camada 1 — Decisão de chamar a API (check_sell_screen.dart:64-77)

if (cartForEligibility != null &&
cartForEligibility.saleEcommerce == false &&
cartForEligibility.customer?.isEmployee == true &&
(store?.channel == StoreChannel.lojaPropria ||
store?.channel == StoreChannel.outlet)) {
await employeeSalesController.fetchEligibility(...);
}

Pré-requisitos para sequer consultar a API de elegibilidade:

  1. cartDTO != null
  2. saleEcommerce == false (loja física)
  3. customer.isEmployee == true
  4. store.channel ∈ {lojaPropria, outlet}

Se qualquer um falhar, isEligible permanece false (default) e o bottom sheet abre direto no fluxo manual.

Camada 2 — Decisão de qual UI exibir (check_sell_product_discount.dart:204)

Widget _renderDiscountFields(FormComposer formComposer) {
if (employeeSalesController.isEligible.value) {
return _EmployeeDiscountSection(...); // checkbox de funcionário
}
return Column(...); // tabs Novo Preço / Percentual
}

A API pode retornar isEligible=false mesmo para funcionário (sem cota, regra de negócio backend etc.); neste caso a Camada 2 cai no fluxo manual.

2.2. Saldo de Pares

Arquivo: employee_sales_controller.dart

// Unidades restantes efetivas (API - já usadas no cart)
effectiveRemainingUnits = remainingUnitsFromApi - _currentCartEmployeeItems
isEligibleAndHasBalance = isEligible && effectiveRemainingUnits > 0

// Pode aplicar ao item se está elegível, tem saldo e o desconto funcionário
// é melhor que o desconto vigente do item (qualquer origem)
canApplyToItem(item) = isEligibleAndHasBalance && _employeeDiscountBetterThanCurrent(item)

_currentCartEmployeeItems tem três pontos de mutação:

LocalOperaçãoObservação
fetchEligibility:45inicialização: cartItems.where(isEmployeeDiscount).lengthItera apenas cartItems — não considera recommendedItems pré-existentes
applyToItem:84_currentCartEmployeeItems++Conta corretamente quando aplicado em recomendados
removeFromItem:93max(0, _currentCartEmployeeItems - 1)Conta corretamente quando removido de recomendados

Gap real (apenas na inicialização): se ao recarregar o cart já houver itens em recommendedItems com isEmployeeDiscount = true, o contador inicial subestima o consumo e o saldo aparenta ser maior do que de fato é. Aplicação/remoção subsequente está correta.

2.3. Comparação com desconto vigente (_employeeDiscountBetterThanCurrent)

// employee_sales_controller.dart:63-69
final currentDiscountPercent =
(item.unitPrice - item.unitPriceWithDiscount) / item.unitPrice * 100;
return discountPercentage.value! > currentDiscountPercent;

A comparação é genérica — vale para qualquer desconto vigente no item (promoção, manual anterior, funcionário já aplicado). O warning específico de promoção só aparece quando hasSaleDiscount(item) é verdadeiro (item desconto não-funcionário com preço atual menor que unitPrice).

2.4. Aplicação do Desconto (applyToItem)

// employee_sales_controller.dart:76-86
unitPriceWithDiscount = double.parse(
(unitPrice * (1 - pct / 100)).toStringAsFixed(2)
)
discount = CartItemDTODiscountType.percentage
discountValue = pct
isEmployeeDiscount = true
_currentCartEmployeeItems++
  • Desconto funcionário não está sujeito a maxDiscountPercent da loja.
  • Estado do checkbox é controlado por cartItemDTO.isEmployeeDiscount (mutado imediatamente pelo onTap, sem aguardar o APLICAR final).
  • CANCELAR no bottom sheet reverte o estado se diferiu do original capturado em _originalIsEmployeeDiscount.

2.5. Mensagens de Aviso (_EmployeeDiscountSection:524-534)

CondiçãoMensagemBloqueia?
hasSaleDiscount && canApply"O desconto será aplicado sobre o preço cheio do produto."Não (informativa)
hasSaleDiscount && !canApply && !isAlreadyApplied"O desconto de funcionário é menor que o preço promocional atual."Sim (checkbox desabilitado)
!isEligibleAndHasBalance && !isAlreadyApplied"Limite de pares com desconto atingido."Sim (sem saldo de cota)

2.6. Fluxograma: Funcionário


3. Desconto Manual (fluxo Não-Funcionário)

Aplicado quando EmployeeSalesController.isEligible.value == false, independentemente de o cliente ser funcionário ou não.

3.1. Pré-Gate de Visibilidade (hasMaxDiscountPercent)

Arquivos: check_sell_products.dart (cart) e check_sell_recommended.dart:291 (recomendados)

Visibility(
visible: checkSellController.hasMaxDiscountPercent,
child: ... ZzTextButton('EDITAR') ...,
)

Se maxDiscountPercent <= 0, o botão EDITAR fica invisível e o bottom sheet nunca abre. O desconto manual está completamente bloqueado.

3.2. UI: Dois Modos de Entrada

Arquivo: check_sell_product_discount.dart

ModoCampoValidação
Novo preçoValor monetáriofieldMinMaxValue(val, minAllowedPrice, unitPrice)
PercentualInteiro 0–99fieldMinMaxValuePercentIntegerProduct(val, 0, maxAllowedPercent)

Ambos delegam ao CartController que delega ao DiscountLimitUtils.

3.3. Limite Máximo por Item

Arquivo: discount_limit_utils.dart linha 61

maxByItem = unitPrice × (maxDiscountPercent / 100)
currentItemDiscount = getCartItemDiscountValue(item)
otherItemsDiscount = max(0, cart.itemsDiscount - currentItemDiscount)
cartMaxDiscount = totalWithoutShipmentAndDiscount × (maxDiscountPercent / 100)
remainingForThisItem = cartMaxDiscount - otherItemsDiscount
allowedDiscount = min(maxByItem, remainingForThisItem)
return max(0, round2(allowedDiscount))

Gap: cart.itemsDiscount soma apenas cartItems. Descontos em recommendedItems não entram em otherItemsDiscount. A premissa exige que entrem.

3.4. Limite Máximo do Pedido (Combinado)

Arquivo: discount_limit_utils.dart linha 20 e 29

maxAllowed = totalWithoutShipmentAndDiscount × (maxDiscountPercent / 100)
currentApplied = itemsDiscount + (crmBonus.rescuedBonus ?? 0)
exceeded = currentApplied - maxAllowed → se > 0: limite excedido

Gap: itemsDiscount não inclui descontos de recommendedItems.

3.5. Pós-aplicação: Recálculo de CRM

Arquivo: check_sell_product_discount.dart linha 266-289

if (hasAppliedCrmBonus &&
isCombinedDiscountLimitExceededForCurrentStore(cartValue: updatedCart)) {
cartController.clearCrmBonusExceedsEffectiveLimitFlag();
// ... fecha bottom sheet ...
cartController.requestCrmBonusBottomSheetReopen(
keepCurrentValue: true,
showValidationError: true,
clearAppliedValueOnDismiss: true,
);
}

Este recálculo de CRM só ocorre no caminho de cart items (onConfirmDiscount == null). Itens recomendados pulam essa verificação porque o callback recomendado encerra o fluxo antes (linha 256-259).

3.6. Fluxograma: Não-Funcionário


4. Comportamento de Totais com Recomendados

CálculoInclui recomendados?Arquivo
itemsDiscountNãocart_dto.dart:61 - itera cartItems
totalWithoutShipmentAndDiscountNãocart_dto.dart - itera cartItems
total (pedido)Nãocart_dto.dart:374
isCombinedDiscountLimitExceededNãousa itemsDiscount
_currentCartEmployeeItems (inicialização)Não (gap)employee_sales_controller.dart:45
_currentCartEmployeeItems (aplicação/remoção)Simemployee_sales_controller.dart:84,93

5. Gaps vs Premissas Requeridas

#PremissaComportamento AtualGap
1Qtd máxima de pares = cart + recomendadosApenas a inicialização em fetchEligibility:45 filtra somente cartItems. Mutações via applyToItem/removeFromItem contam corretamente recomendados.Reinício/recarregamento de carrinho com recomendados já marcados isEmployeeDiscount=true subestima o contador.
2Max discount por item considera cart + recomendadosgetMaxAllowedDiscountValueForCartItem usa itemsDiscount (só cartItems)otherItemsDiscount não inclui desconto de recomendados
3Max discount do pedido = soma cart + recomendadosisCombinedDiscountLimitExceeded usa itemsDiscount (só cartItems)itemsDiscount em CartDTO não itera recommendedItems
4Recálculo de CRM após desconto em recomendadocheck_sell_product_discount.dart:266-289 só roda quando onConfirmDiscount == null (cart items)Itens recomendados pulam a verificação de limite combinado e a reabertura de CRM bottom sheet

6. Fórmulas Resumidas (Validadas no Código)

Preço do Item

// Funcionário (employee_sales_controller.dart:78)
unitPriceWithDiscount = round2(unitPrice × (1 - pct / 100))

// Não-funcionário valor (check_sell_controller.dart:96)
discountValue = unitPrice - novoPreco
unitPriceWithDiscount = unitPrice - discountValue

// Não-funcionário percentual (check_sell_controller.dart:98-101)
discount = unitPrice × (pct / 100)
unitPriceWithDiscount = round2(unitPrice - discount)

Desconto Efetivo por Item

// discount_limit_utils.dart:11
getCartItemDiscountValue:
value → discountValue
percentage → unitPrice - unitPriceWithDiscount
none → 0

Limite por Item

// discount_limit_utils.dart:61
maxByItem = unitPrice × (maxDiscountPercent / 100)
otherItemsDiscount = max(0, itemsDiscount - thisItemDiscount)
cartMaxDiscount = totalWithoutShipmentAndDiscount × (maxDiscountPercent / 100)
remainingForThisItem = cartMaxDiscount - otherItemsDiscount
allowedDiscount = min(maxByItem, remainingForThisItem)

// Derivados
minAllowedPrice = unitPrice - allowedDiscount
maxAllowedPercent = floor(allowedDiscount / unitPrice × 100).clamp(0, 99)

Limite do Pedido

// discount_limit_utils.dart:20-58
maxAllowed = totalWithoutShipmentAndDiscount × (maxDiscountPercent / 100)
currentApplied = itemsDiscount + rescuedBonus
exceeded = currentApplied - maxAllowed → exceeded > 0 = limite violado

Elegibilidade Funcionário

// Gate Camada 1 (check_sell_screen.dart:64-77)
deveChamarApi = cart != null
&& saleEcommerce == false
&& customer.isEmployee == true
&& store.channel ∈ {lojaPropria, outlet}

// Gate Camada 2 (check_sell_product_discount.dart:204)
mostraFluxoFuncionario = isEligible.value // setado a partir da resposta da API

// Saldo e aplicabilidade (employee_sales_controller.dart:21-25,59-69)
effectiveRemainingUnits = remainingUnitsFromApi - _currentCartEmployeeItems
isEligibleAndHasBalance = isEligible && effectiveRemainingUnits > 0
canApplyToItem(item) = isEligibleAndHasBalance
&& discountPct > currentDiscountPct(item)

CRM Efetivo

// discount_limit_utils.dart:128
crmByPercentage = itemsTotal × (percentageCrm / 100)
crmByLimit = maxAllowed - itemsDiscount
effectiveLimit = min(crmByPercentage, min(crmByLimit, totalBonus))

7. Arquivos Chave

ArquivoResponsabilidade
discount_limit_utils.dartCORE - todas as fórmulas de limite
cart_dto.dartModel do carrinho; itemsDiscount e recommendedItems
cart_item_dto.dartModel do item; campos de desconto
cart_controller.dartLifecycle; changeWithSkipTotals para recomendados
check_sell_screen.dartCamada 1 do gate de elegibilidade (decide chamar API)
employee_sales_controller.dartElegibilidade, contador de cota e aplicação de desconto funcionário
check_sell_product_discount.dartCamada 2 do gate (decide qual UI) + form e validação
check_sell_recommended.dartUI dos cards recomendados + botão EDITAR
store_payment_config.dartConfiguração maxDiscount por loja